Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 21 章 数据保存

作者:Adam Freeman
翻译:陈广
日期:2019-5-31


在本章中,我将继续描述 Entity Framework Core 提供的高级功能,重点是那些与添加或更新数据有关的功能。我将向您展示如何选择用于存储值的数据类型、如何格式化或验证值、如何向应用程序的其他部分隐藏数据值以及如何检测多个客户端的并发更新。表21-1为本章简述。

表 21-1:高级存储功能简述

问题 回答
它们是什么? 这些功能允许您更改数据存储在数据库中的方式,覆盖常规行为。这些包括从选择特定的 SQL 类型到检测两个用户何时更新相同的数据。
它们有何用途? 当您正在对现有数据库建模时,或者当您的应用程序有特殊需求时,这些功能可能非常有用,而这些需求很难使用标准功能来满足。
如何使用它们 这些功能是通过模型类成员和 Fluent API 语句的组合应用的。
是否有任何缺陷或限制? 其中一些功能改变了数据映射到数据模型对象的方式,如果不小心执行,就会导致奇怪的结果。
有没有其他选择? 大多数项目可以使用标准的 Entity Framework Core 功能来存储数据。

表 21-2 为本章摘要

表21-2:本章摘要

问题 解决方案 清单
更改用于表示值的 SQL 数据类型 使用HasColumnTypeHasMaxLength方法 1-6
当值在剩余应用程序可用或存储于数据库之前,处理它们 使用支持字段 7-11
从应用程序的 MVC 部分隐藏数据值 使用影子属性 12-15
设置默认值 使用HasDefaultValue方法 16-20
检测并发更新 使用并发令牌或启用行版本控制 21-30

准备本章

对于本章,我将继续使用我在第19章中创建并在第20章中修改的 AdvancedApp 项目。为了准备本章,我修改了 Home 控制器中的代码,如清单21-1所示,将相关数据包含在由Index action 执行的查询中,并整理Update方法,以便更新所有属性。

提示:如果您不想跟随构建示例项目的过程,可以从本书的源代码库下载所有所需的文件,这些文件可在 https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc 上找到。

清单 21-1:Controllers 文件夹下的 HomeController.cs 文件,简化代码

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.Include(e => e.OtherIdentity));
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                    .First(e => e.SSN == SSN
                        && e.FirstName == firstName
                        && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (context.Employees.Count(e => e.SSN == employee.SSN
                && e.FirstName == employee.FirstName
                && e.FamilyName == employee.FamilyName) == 0)
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Delete(Employee employee)
        {
            context.Attach(employee);
            employee.SoftDeleted = true;
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

为了向用户显示SecondaryIdentity对象的详细信息,我对 Views/Home 文件夹中的 Index.cshtml 视图进行了清单21-2所示的更改。我还借此机会删除了前一章中添加的一些内容,这些内容不再是必需的。

清单 21-2:Views/Home 文件夹下的 Index.cshtml 文件,添加内容

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th>Other Name</th>
            <th>In Use</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td>@e.Salary</td>
                <td>@(e.OtherIdentity?.Name ?? "(None)")</td>
                <td>@(e.OtherIdentity?.InActiveUse.ToString() ?? "(N/A)")</td>
                <td class="text-right">
                    <form>
                        <input type="hidden" name="SSN" value="@e.SSN" />
                        <input type="hidden" name="Firstname" value="@e.FirstName" />
                        <input type="hidden" name="FamilyName"
                               value="@e.FamilyName" />
                        <button type="submit" asp-action="Delete" formmethod="post"
                                class="btn btn-sm btn-danger">
                            Delete
                        </button>
                        <button type="submit" asp-action="Edit" formmethod="get"
                                class="btn btn-sm btn-primary">
                            Edit
                        </button>
                    </form>
                </td>
            </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

使用dotnet run启动应用程序,导航至 http://localhost:5000,单击【Create】按钮,并使用表21-3所示的值存储三个Employee对象。

表 31-3:于创建示例对象的数据值

SSN FirstName FamilyName Salary Other Name In Active Use
420-39-1864 Bob Smith 100000 Robert Checked
657-03-5898 Alice Jones 200000 Allie Checked
300-30-0522 Peter Davies 180000 Pete Unchecked

在创建所有三个对象之后,您将看到如图21-1所示的布局。

图21-1 运行示例应用程序

指定 SQL 数据类型

无论是在代码优先项目的迁移中还是在数据库优先项目的脚手架阶段,Entity Framework Core 都会自动处理 .NET 和 SQL 数据类型之间的映射。数据库服务器并不总是支持相同的数据类型 —— 或使用相同的方式实现它们 —— 数据库提供程序有责任做出适当的类型选择。例如,这意味着针对两个不同数据库服务器的相同数据模型的迁移可能使用不同的 SQL 类型。

大多数提供程序使用相似类型,但有一些变化。表214显示了用于官方 SQL 服务器数据库提供程序和最受欢迎的 MySQL 提供程序的 .NET Core 基元类型的映射。

警告:对于数据库提供程序的未来版本,这些映射可能会更改。创建迁移以确定提供程序使用哪种数据类型。

表 21-4:.NET Core 基元类型的数据库提供程序映射

.NET Core 类型 SQL Server 类型 MySQL 类型
int int int
long bigint bigint
bool bit bit
byte tinyint tinyint
double float double
char int tinyint
short smallint smallint
float real float
decimal decimal(18,2) decimal(65,30)
string nvarchar(max) longtext
TimeSpan time time(6)
DateTime datetime2 datetime(6)
DateTimeOffset datetimeoffset datetime(6)
Guid uniqueidentifier char(36)

如果由 Entity Framewrok Core 选择的类型不合适,您可以更改用于存储值的 SQL 数据类型。此功能最常用于调整类型,以确保应用程序生成的值的足够精度,或选择较小的数据类型来限制可存储的值。

Employee类的Salary属性被表示为 .NET 的decimal,它被 SQL Server 提供程序映射为 SQL 类型decimal(18,2)(意思是小数点左边有 18 位数,右边是 2 位数的数字)。这比我表达个人收入所需的精度要高,在清单21-4中,我告诉 Entity Framework Core 使用一个不太精确的类型,覆盖第19章中创建第一次迁移时所选择的默认类型。

警告:Entity Framework Core 不验证您指定的类型或最大长度,这意味着您必须确保所选择的类型和大小适合需要。

清单 21-4:Models 文件夹下的 AdvancedContext.cs 文件,更改数据类型

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options)
        {
            //ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)");

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });
        }
    }
}

HasColumnType方法用于为使用Property方法选择的属性指定 SQL 类型。在清单中,我指定了decimal(8,2)类型,它将小数点左边的数字减少到 8。

注意:如果您不喜欢使用 Fluent API,可以使用Column特性为属性指定 SQL 类型,并提供一个TypeName参数:[Column(TypeName = "decimal(8, 2)")]

指定最大长度

如果存储在数据库中的值是数组数据类型,如stringint[],则可以向 Entity Framework Core 提供关于需要存储的数据量的指导,而不必显式选择 SQL 数据类型,这意味着您可以影响数据类型的选择,而无需做出特定于单个数据库提供程序或服务器的决策。在清单21-5中,我使用了 Fluent API 来设置由SecondaryIdentity定义的Name属性的最大长度。

注意:如果您不喜欢使用 Fluent API,则可以通过使用MaxLength特性装饰属性来指定最大长度。

清单 21-5:Models 文件夹下的 AdvancedContext.cs 文件,设置最大长度

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options)
        {
            //ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
        }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)");

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

HasMaxLength方法用于指定一个最大长度,我使用它为Name属性指定 100 字符的最大长度。

更新数据库

更改数据类型或指定最大长度需要迁移才能更新数据库。在 AdvancedApp 项目文件夹中运行清单 21-6 所示的命令,创建并应用一个新的迁移。

清单 21-6:创建并应用数据库迁移

dotnet ef migrations add ChangeType
dotnet ef database update

如果您检查 Migrations 文件夹下的<timestamp>_ChangeType.cs文件的Up方法,将看到HasColumnTypeHasMaxLength方法的效果。

...
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterColumn<string>(
        name: "Name",
        table: "SecondaryIdentity",
        maxLength: 100,
        nullable: true,
        oldClrType: typeof(string),
        oldNullable: true);

    migrationBuilder.AlterColumn<decimal>(
        name: "Salary",
        table: "Employees",
        type: "decimal(8,2)",
        nullable: false,
        oldClrType: typeof(decimal));
}
...

我在清单21-4中指定的数据类型将容纳小数位前有8位数字。要查看当一个数字超过可用存储空间时会发生什么,请使用dotnet run启动应用程序,导航到 http://localhost:5000,然后单击【Edit】按钮对其中一个显示的项进行编辑。更改【Salary】字段值为 100000000(1 后面跟 8 个 0),并单击【Save】按钮。Entity Framework Core 将试图更改数据库,但由于Salary值比指定类型有更多的数字,导致报告一个错误,如图21-2所示。您遇到的具体错误将取决于不匹配的性质,但重点是必须确保应用程序不会尝试(或允许用户尝试)存储无法由数据库表示的值。

图21-2 超过数据值的可用存储空间

验证或格式化数据值

在许多应用程序中,实体类只是属性的集合,可以方便地访问数据库中的数据。然而,在某些情况下,提供对数据值的直接访问可能会出现问题,因为需要某种形式的处理或验证。

Entity Framework Core 支持幕后字段(backing fields),它将值存储在数据库中,但对应用程序的其余部分不可用,而是可以通过属性传递的数据进行访问。通过演示可以更容易地理解这一点,在清单21-7中,我在Employee类中更改了Salary属性,使它不再提供对数据库中值的直接访问,而是使用幕后字段。

清单 21-7:Models 文件夹下的 Employee.cs 文件,定义幕后字段

using System;

namespace AdvancedApp.Models
{
    public class Employee
    {
        private decimal databaseSalary;
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary
        {
            get => databaseSalary * 2;
            set => databaseSalary = Math.Max(0, value);
        }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
    }
}

幕后字段名为databaseSalary。幕后字段必须具有与要使用的属性兼容的类型,本例中,幕后字段和Salary属性都使用decimal类型。通过使用幕后字段,我可以重新使用Salary属性来验证或转换存储在数据库中的值。在清单中,Salary属性的 get 访问器返回幕后字段值的两倍,而 set 访问器确保可以分配给幕后字段的最小值为零,从而防止出现负值。

仅仅向实体类添加一个幕后字段是不够的,因为 Entity Framework Core 只是假设它应该继续使用Salary属性。在清单 21-8 中,我使用了 Fluent API 告诉 Entity Framework Core 幕后字段以及如何使用它。

注意:幕后字段仅能使用 Fluent API 来进行配置。特性不支持此功能。

清单 21-8:Models 文件夹下的 AdvancedContext.cs 文件,设置幕后字段

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)")
                .HasField("databaseSalary")
                .UsePropertyAccessMode(PropertyAccessMode.Field);

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new
                {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new
                {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

我可以通过将调用链接到现有的 Fluent API 语句来进一步配置属性,该语句选择Salary属性,我在上一节中使用该属性来更改数据类型。我使用HasField方法配置幕后字段,将字段的名称指定为参数。使用幕后字段的方式是通过调用UsePropertyAccessMode方法来配置的,该方法指定了PropertyAccessMode枚举中的一个值,如表21-5所述。

提示:您不必使用UsePropertyAccessMode来配置幕后字段,但这样做可以确保 Entity Framework Core 以您预期的方式使用该字段,并使阅读您的 Fluent API 语句的其他开发人员清楚地看到后台字段的用途。

表 21-5:PropertyAccessMode 的值

名称 描述
FieldDuringConstruction 这是默认行为,它告诉 Entity Framework Core 在第一次创建对象时使用幕后字段,之后的所有操作使用属性,包括更改检测。
Field 此值告诉 Entity Framework Core 忽略属性,总是使用幕后字段。
Property 此值告诉 Entity Framework Core 总是使用属性而忽略幕后字段

选择正确的PropertyAccessMode值非常重要,因为此值会导致 Entity Framework Core 以本质上不同的方式运行。在清单 21-8 中,幕后字段始终是true值,因此我使用了Field值,它确保 Entity Framework Core 在使用查询数据创建对象时直接将数据库中的值分配给幕后字段,并在向数据库写入更新时使用幕后字段值。

添加幕后字段时不需要对数据库进行更改,因为此功能只影响数据值映射到实体类的方式。若要查看本节更改的效果,请使用dotent run启动应用程序,运行并导航到 http://localhost:5000。当 Entity Framework Core 查询Employee对象数据时,它将Salary列的值分配给幕后字段。当 Razor 视图枚举Employee对象时,它读取Salary属性的值,而 get 访问器将返回存储于数据库中的双倍值,生成结果如图21-3所示。

图21-3 通过备份字段的传递访问值

要查看更改属性值是如何影响幕后字段的,单击某个Employee对象的【Edit】按钮,在【Salary】栏中输入120000

我使用PropertyAccessMode.Field值配置幕后字段,这意味着用于更新数据库的是幕后字段的值,而不是属性。当 Entity Framework Core 执行更改检测时,它使用幕后字段值而不是Salary属性的 get 访问器来更新数据库。要确认这种情况,请从 Visual Studio 【工具】菜单中选择【SQL Server】➤【New Query】,连接到数据库,并执行清单21-9所示的查询。

清单 21-9:查询数据库

USE AdvancedDb
SELECT * FROM Employees

查询的结果表明,当 Entity Framework Core 更新 Employees 表中的Salary列时使用了幕后字段,如表21-6所示。

表 21-6:数据库结果

FamilyName FirstName SSN Salary
Davies Peter 300-30-0522 180000
Jones Alice 657-03-5898 200000
Smith Bob 420-39-1864 120000

如果我使用的是FieldDuringConstruction值,在更新数据库时,Entity Framework Core 将使用Salary属性 get 访问器来获取值。

更新后的值仍由应用程序的其余部分通过Salary属性 get 访问器读取,后者仍然不知道幕后字段。这反映在应用程序的 ASP.NET Core MVC 部分显示的视图中,如图21-4所示,它显示了存储在数据库中的值的两倍。

图21-4 使用幕后字段更新值

避免幕后字段选择性更新陷阱

在实现不总是更新与其关联的幕后字段的 set 访问器时,必须小心。为了演示这个问题,我更改了Salary属性的 set 访问器,以便它只更新偶数值的幕后字段,如清单21-10所示。我还更改了Salary属性的 get 访问器,以便它返回幕后字段的未修改的值,这将使问题更容易理解。

清单 21-10:Models 文件夹下的 Employee.cs 文件,选择性更新字段

using System;

namespace AdvancedApp.Models
{
    public class Employee
    {
        private decimal databaseSalary;
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary
        {
            get => databaseSalary;
            set
            {
                if (value % 2 == 0)
                {
                    databaseSalary = value;
                }
            }
        }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
    }
}

使用dotnet run启动应用程序,导航至 http://localhost:5000,单击某一个【Edit】按钮。在【Salary】栏输入奇数,如101,单击【Save】按钮。Entity Framework Core 将以 0 而不是您输入的数值更新数据库,如图 21-5 所示。

图21-5 选择性幕后字段更新的效果

只有 Entity Framework Core 理解幕后字段及如何使用它们。在 ASP.NET Core MVC 应用程序中,MVC 模型绑定器还负责创建对象。当浏览器向控制器发送 HTTP POST 请求时,模型绑定器创建一个新的Employee对象,并使用用户发送的值设置它的属性。与实体框架核心创建Employee不同,模型绑定器忽略幕后字段,并使用Salary属性的 set 访问器分配来自 HTTP 请求的值。但是 set 访问器忽略了 MVC 模型绑定器分配的值,因为它不是偶数。这意味着当Employee对象被传递到 Context 对象时,幕后字段有其默认值(对于十进制值为零),以便 Entity Framework Core 可以执行更新。

避免此问题的最佳方法是编写总是更新其幕后字段的 set 访问器。如果这是不可能的,那么您可以查询数据库,以便实体框架核心使用现有值创建一个对象,并将来自http请求的值直接应用到它,如清单21-11所示。

清单 21-11:Controllers 文件夹下的 HomeController.cs 文件,直接设置值

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.Include(e => e.OtherIdentity));
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                    .First(e => e.SSN == SSN
                        && e.FirstName == firstName
                        && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee, decimal salary)
        {
            Employee existing = context.Employees.Find(employee.SSN,
            employee.FirstName, employee.FamilyName);
            if (existing == null)
            {
                context.Add(employee);
            }
            else
            {
                existing.Salary = salary;
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Delete(Employee employee)
        {
            context.Attach(employee);
            employee.SoftDeleted = true;
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

我更改了Update方法,以便它查询数据库中现有的Employee对象,该对象为我提供了一个由 Entity Framework Core 创建的其幕后字段被正确初始化的对象。然后将用户在Salary字段中指定的值赋值,通过向 action 方法添加一个salary参数来接收该值,如下所示:

...
public IActionResult Update(Employee employee, decimal salary) {
...

这为我提供了用户输入到 HTML 表单中的值,以便我可以将它分配给Salary属性。

...
existing.Salary = salary;
...

如果 set 访问器更新幕后字段,Entity Framework Core 将检测更改值,并更新数据库。如果 set 访问器丢弃新值(本例因为它是奇数),那么 Entity Framework Core 将检测到没有变化,存储在数据库中的原始值将被保留。

启动应用程序,导航至 http://localhost:5000,重复在 Salary 字段中输入偶数(例如100),然后输入奇数(例如101)的过程。这一次,保存更改时,奇数将被丢弃,但偶数仍将存储在数据库中,如图21-6所示。

图21-6 避免将幕后字段设置为其类型的默认值

从 MVC 应用程序隐藏数据值

执行数据库操作需要某些类型的数据,但应用程序的 MVC 部分不应该访问这些数据,因为数据是敏感的,或者因为您希望将应用程序的焦点放在用户直接看到的数据上。在这些情况下,您可以使用影子属性(shadow properties),这些属性是在数据模型中定义的,而不是在表示该数据的实体类中定义的。

跟踪对象存储在数据库中的时间是影子属性最常见的用途,它提供的信息在诊断问题时可能很有用,但用户不感兴趣,不应该通过应用程序的 ASP.NET MVC Core 部分公开这些信息。

影子属性使用 Fluent API 创建。在清单 21-12 中,我向Employee类添加了一个名为LastUpdated的影子属性。

注意:影子属性仅能使用 Fluent API 进行配置,特性不支持此功能。

清单 21-12:Models 文件夹下的 AdvancedContext.cs 文件,定义影子属性

using Microsoft.EntityFrameworkCore;
using System;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)")
                .HasField("databaseSalary")
                .UsePropertyAccessMode(PropertyAccessMode.Field);

            modelBuilder.Entity<Employee>().Property<DateTime>("LastUpdated");

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new
                {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new
                {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

影子属性是通过使用Entity方法来选择类,然后调用Property方法来定义的。这是一个与前面示例中使用的Property方法的不同版本。该参数指定影子属性的名称,类型参数用于指定其数据类型;清单21-12语句中的 Fluent API 告诉 Entity Framework Core 存在一个名为LastUpdatedDateTime属性。

提示:您可以像任何其他属性一样,将诸如IsRequired的附加调用链接到方法以配置阴影属性。

向现有数据库添加阴影属性需要迁移。在 AdvancedDb 项目文件夹中运行清单21-13所示的命令,以创建一个名为 ShadowProperty 的迁移,并将其应用于数据库。

清单 21-13:创建并应用迁移

dotnet ef migrations add ShadowProperty
dotnet ef database update

如果您检查在 Migrations 文件夹下创建的<timestamp>_ShadowProperty.cs文件中的Up方法,将看到 Entity Framework Core 是如何为影子属性设置列的,即使在Employee类中没有相应的属性。

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<DateTime>(
        name: "LastUpdated",
        table: "Employees",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}

访问影子属性值

影子属性可以通过 context 类访问。在清单21-14中,我修改了Advanced控制器中的Update action,以便在对数据库进行更改时为LastUpdated的影子属性分配一个值。

清单 21-14:Controllers 文件夹下的 HomeController.cs 文件,更新影子属性

...
[HttpPost]
public IActionResult Update(Employee employee, decimal salary)
{
    Employee existing = context.Employees.Find(employee.SSN,
    employee.FirstName, employee.FamilyName);
    if (existing == null)
    {
        context.Entry(employee)
            .Property("LastUpdated").CurrentValue = System.DateTime.Now;
        context.Add(employee);
    }
    else
    {
        existing.Salary = salary;
        context.Entry(existing)
            .Property("LastUpdated").CurrentValue = System.DateTime.Now;
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

我使用了第12章中的Entry方法来访问 Entity Framework Core 更改检测功能,但是返回的对象也被用来通过其Property方法访问影子属性。可以使用CurrentValue属性读取或设置影子属性的值,在清单中,我将当前时间分配给影子属性。

注意:影子属性只能通过 context 对象访问,这意味着即使您在控制器中直接使用 context,就像我在本例中所做的那样,MVC 模型绑定器将无法为任何影子属性设置值,即使 HTTP 请求包含的一个值。

在查询中包含影子属性

EF.Property静态方法用于在 LINQ 查询中包含阴影属性,这意味着您可以将影子属性合并到查询中。在清单21-15中,我使用EF.Property方法通过影子属性值对数据库中的对象进行排序。

清单 21-15:Controllers 文件夹下的 HomeController.cs 文件,使用影子属性查询

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees.Include(e => e.OtherIdentity)
                .OrderByDescending(e => EF.Property<DateTime>(e, "LastUpdated")));
        }
        // ...其它省略...
    }
}

EF.Property方法接受要查询的对象和影子属性的名称。必须将类型参数设置为用于在 Fluent API 语句中定义属性的类型。要查看影子属性的使用情况,请使用dotnet run启动应用程序,然后导航到 http://localhost:5000。

编辑某一个Employee对象,当单击【Save】按钮时,您将看到它会显示到表格的第一行,如图21-7所示。

图21-7 使用阴影属性对 LINQ 查询中的对象进行排序

设置默认值

在上一节中设置LastUpdated属性时,我负责在两个地方设置值:对象第一次往数据库中存储时设置,修改现有对象时再次设置。

我可以通过要求 Entity Framework Core 在存储新对象时为LastUpdated属性设置默认值来移除其中一个语句。为了为LastUpdated属性设置默认值,我在 Fluent API 语句中使用了HasDefaultValue方法,如清单21-16所示。

注意:默认值只能使用 Fluent API 指定,特性不支持此功能。

清单 21-16:Models 文件夹下的 AdvancedContext.cs 文件,配置默认值

using Microsoft.EntityFrameworkCore;
using System;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)")
                .HasField("databaseSalary")
                .UsePropertyAccessMode(PropertyAccessMode.Field);

            modelBuilder.Entity<Employee>().Property<DateTime>("LastUpdated")
                .HasDefaultValue(new DateTime(2000, 1, 1));

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new
                {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new
                {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

HasDefaultValue方法用于指定一个默认值,该默认值将在数据库中创建新行时使用。您可以通过在存储对象时提供属性的值来覆盖默认值,但如果没有,则将使用传递到HasDefaultValue方法的默认值。

设置默认值需要进行新的迁移。运行清单21-17所示的命令,在 AdvancedApp 项目文件夹中创建一个名为 DefaultValue 的迁移,并将其应用于数据库。

清单 21-17:创建并应用数据库迁移

dotnet ef migrations add DefaultValue
dotnet ef database update

如果您检查 Migrations 文件夹下创建的<timestamp>_DefaultValue.cs文件中的Up方法,将看到与LastUpdated列关联的默认值已更改为2000年1月1日,它是清单21-16中指定的日期。

...
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterColumn<DateTime>(
        name: "LastUpdated",
        table: "Employees",
        nullable: false,
        defaultValue: new DateTime(2000, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
        oldClrType: typeof(DateTime));
}
...

显示默认值

为了在应用程序的其余部分使用这个示例,我将向用户显示LastUpdate属性的值。在清单21-18中,我通过修改Employee类将LastUpdated属性从影子属性提升到了应用程序的其他部分可以访问的属性。这不会改变属性的行为,只意味着属性值不必通过 context 对象访问。

清单 21-18:Models 文件夹下的 Employee.cs 文件,添加属性

using System;

namespace AdvancedApp.Models
{
    public class Employee
    {
        private decimal databaseSalary;
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary
        {
            get => databaseSalary;
            set
            {
                if (value % 2 == 0)
                {
                    databaseSalary = value;
                }
            }
        }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
        public DateTime LastUpdated { get; set; }
    }
}

在清单 21-19 中,我更改了 Home 控制器的Update方法,移除了在对象存储时设置LastUpdated属性的语句,转而使用默认值。

清单 21-19:Controllers 文件夹下的 HomeController.cs 文件,禁用语句

[HttpPost]
public IActionResult Update(Employee employee, decimal salary)
{
    Employee existing = context.Employees.Find(employee.SSN,
    employee.FirstName, employee.FamilyName);
    if (existing == null)
    {
        //context.Entry(employee)
        //    .Property("LastUpdated").CurrentValue = System.DateTime.Now;
        context.Add(employee);
    }
    else
    {
        existing.Salary = salary;
        context.Entry(existing)
            .Property("LastUpdated").CurrentValue = System.DateTime.Now;
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}

最后一步是向由 Home 控制器使用的 Index 视图添加一个列,以显示LastUpdated属性的值,如清单 21-20 所示。

清单 21-20:Views/Home 文件夹下的 Index.cshtml 文件,添加一个列

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th>Other Name</th>
            <th>In Use</th>
            <th>Last Updated</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
    <tr>
        <td>@e.SSN</td>
        <td>@e.FirstName</td>
        <td>@e.FamilyName</td>
        <td>@e.Salary</td>
        <td>@(e.OtherIdentity?.Name ?? "(None)")</td>
        <td>@(e.OtherIdentity?.InActiveUse.ToString() ?? "(N/A)")</td>
        <td>@e.LastUpdated.ToLocalTime()</td>
        <td class="text-right">
            <form>
                <input type="hidden" name="SSN" value="@e.SSN" />
                <input type="hidden" name="Firstname" value="@e.FirstName" />
                <input type="hidden" name="FamilyName"
                       value="@e.FamilyName" />
                <button type="submit" asp-action="Delete" formmethod="post"
                        class="btn btn-sm btn-danger">
                    Delete
                </button>
                <button type="submit" asp-action="Edit" formmethod="get"
                        class="btn btn-sm btn-primary">
                    Edit
                </button>
            </form>
        </td>
    </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

要查看默认值,使用dotnet run启动应用程序,导航至 http://localhost:5000,单击【Create】按钮。填充表单并单击【Save】按钮;您将看到新对象的LastUpdated值为January 1, 2000,这是我在清单 21-16 中使用的默认值,如图 21-8 所示。日期的显示将使用系统的默认区域;配图中显示的日期采用的是英国使用的格式。

提示:如果数据库中包含LastUpdated列添加到 Employee 表之前的Employee对象,并且您没有编辑这些对象,您将看到LastUpdated值为Jan 01 0001,如图所示。这是因为默认值仅在创建新对象时应用。当LastUpdated列被创建时,正如您所见,数据库中的现有对象全被赋值为 0。

图21-8 赋予默认值

检测并发更新

大多数 ASP.NET Core MVC 应用程序在用户编辑数据时遵循查询-更新循环。现在数据值通过查询从数据库中检索以便为用户提供一个初始状态,然后对所有更改进行更新。当多个用户并发地执行此循环时,可能会存在问题,如图 21-9 所示。

图21-9 并发更新

客户端 A 和客户端 B 查询相同的数据,并获取相同的值。客户端 A 执行一个更新,紧接着客户端 B 也做了同样的事情。

这可能会导致一系列问题,包括静默覆盖的更新和看似有效更新的异常。某些问题可能需要一段时间才能显现出来, 因为客户端将继续处理不一致或不完整的数据。

使用并发令牌

Entity Framework Core 可被配置为检查与对象关联的值, 以确保自读取数据以来该值未被更改。为此检查选择的属性称为并发令牌(Concurrency Token)。当您不想(或无法)对数据库进行更改以防止并发更新时, 此技术非常有用。在清单 21-21 中,我配置了数据模型,以让Salary属性成为更新Employee对象的并发令牌。

警告:正如您将了解到的, 并发令牌功能有一些严重的限制。如果您能够修改数据库, 则有更好的替代方法, 如下一节所述。

清单 21-21:Models 文件夹下的 AdvancedContext.cs 文件,创建令牌

using Microsoft.EntityFrameworkCore;
using System;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)")
                .HasField("databaseSalary")
                .UsePropertyAccessMode(PropertyAccessMode.Field)
                .IsConcurrencyToken();

            modelBuilder.Entity<Employee>().Property<DateTime>("LastUpdated")
                .HasDefaultValue(new DateTime(2000, 1, 1));

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new
                {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new
                {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

IsConcurrencyToken方法用于告诉 Entity Framework Core 在执行更新时,应当包含查询中属性的现有值,以确保它未被更改。Entity Framework Core 在执行更新之前,只有在知道旧值的情况下, 并发令牌才有效。这需要在 ASP.NET Core MVC 应用程序中做一些工作来处理由 MVC 模型绑定器创建的对象。

提示ConcurrencyCheck特性可用于告诉 Entity Framework Core 将一个属性作为并发令牌使用。

我不想过度地完成此示例,去处理并不总是更新与之关联的幕后字段的影响。在清单21-22中,我简化了Salary属性的 set 访问器,使它只是简单地更新databaseSalary字段。

清单 21-22:Models 文件夹下的 Employee.cs 文件,简化属性 set 访问器

using System;

namespace AdvancedApp.Models
{
    public class Employee
    {
        private decimal databaseSalary;
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary
        {
            get => databaseSalary;
            set => databaseSalary = value;
        }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
        public DateTime LastUpdated { get; set; }
    }
}

为向 Entity Framework Core 提供要检查的Salary旧值,我向 Home 控制器使用的 Edit.chstml 视图添加了隐藏的input元素,如清单 21-23 所示。这将确保Update action 方法接收的Salary属性值是从数据库读取的。

清单 21-23:Views/Home 文件夹下的 Edit.cshtml 文件,添加元素

@model Employee
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}

<h4 class="bg-info p-2 text-center text-white">
    Create/Edit
</h4>
<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <input type="hidden" name="originalSalary" value="@Model.Salary" />
    <div class="form-group">
        <label class="form-control-label" asp-for="SSN"></label>
        <input class="form-control" asp-for="SSN" readonly="@Model.SSN" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FirstName"></label>
        <input class="form-control" asp-for="FirstName"
               readonly="@Model.FirstName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FamilyName"></label>
        <input class="form-control" asp-for="FamilyName"
               readonly="@Model.FamilyName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="Salary"></label>
        <input class="form-control" asp-for="Salary" />
    </div>
    <input type="hidden" asp-for="OtherIdentity.Id" />
    <div class="form-group">
        <label class="form-control-label">Other Identity Name:</label>
        <input class="form-control" asp-for="OtherIdentity.Name" />
    </div>
    <div class="form-check">
        <label class="form-check-label">
            <input class="form-check-input" type="checkbox"
                   asp-for="OtherIdentity.InActiveUse" />
            In Active Use
        </label>
    </div>
    <div class="text-center">
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-secondary" asp-action="Index">Cancel</a>
    </div>
</form>

要确保 Entity Framework Core 能够使用原始的Salary值来执行并发检查,我在 Home 控制器的Update action 方法中添加了一个参数,以接收来自input元素的值,并将它应用于用于执行更新的Employee对象,如清单21-24所示。

清单 21-24:Controllers 文件夹下的 HomeController.cs 文件,更新 Action 方法

...
[HttpPost]
public IActionResult Update(Employee employee, decimal originalSalary)
{
    if (context.Employees.Count(e => e.SSN == employee.SSN
        && e.FirstName == employee.FirstName
        && e.FamilyName == employee.FamilyName) == 0)
    {
        context.Add(employee);
    }
    else
    {
        Employee e = new Employee
        {
            SSN = employee.SSN,
            FirstName = employee.FirstName,
            FamilyName = employee.FamilyName,
            Salary = originalSalary
        };
        context.Employees.Attach(e);
        e.Salary = employee.Salary;
        e.LastUpdated = DateTime.Now;
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

请记住的最重要的事情是在Update方法中查询数据库会破坏并发令牌的目的,因为查询将返回当前在数据库中的值,而不是客户端请求用户编辑的数据时存储的值。

Update方法中,我创建了一个新的Employee对象,并使用来自隐藏input元素的值设置其Salary属性。当用户开始编辑操作时,将使用当时的数据值作为 Entity Framework Core 更改检测进程的基线。我使用Attach方法将Employee对象置于更改管理下,并使用 HTTP POST 请求中收到的值更改Salary属性。

这一系列步骤很难处理,但它允许在 ASP.NET Core MVC 应用程序中使用并发令牌,当您看到此功能如何工作时,它会更有意义。

使用并发令牌无需数据库迁移,因为它仅影响 Entity Framework Core 发送的查询,而不影响数据库本身。使用dotnet run启动应用程序,导航至 http://localhost:5000,单击表中的某个对象的【Edit】按钮,更改Salary值,并单击【Save】按钮。

如果您检查应用程序生成的日志消息,将看到 Entity Framework Core 发送到数据执行更新的 SQL 命令。

...
UPDATE [Employees] SET [LastUpdated] = @p0, [Salary] = @p1
WHERE [SSN] = @p2 AND [FirstName] = @p3 AND [FamilyName] = @p4 AND [Salary] = @p5;
...

高亮的WHERE子句限制了更新,以便它仅应用于 Employees 表中具有特定复合主键和特定Salary值的行。这可以防止在另一个客户端修改了并发令牌时应用更新,因为没有行与UPDATE语句的WHERE子句匹配。Entity Framework Core 会检查有多少行被UPDATE语句的WHERE子句更改。如果更新了一行,它会假定没有并发更新。如果没有行被更新,则 Entity Framework Core 假设并发令牌已经被另一个客户端更改,并报告一个错误。

要查看错误,打开第二个浏览器窗口,并执行一个交错更新:在两个浏览器窗口中为相同的Employee单击【Edit】按钮,更改两个窗口的Salary值,单击两个窗口的【Save】按钮。第二个更新将失败,您将看到图21-10所示的错误信息。

图21-10 并发检查失败

并发检查功能的限制是每次更新都必须修改同一列以指示更改,并且每次更新都必须知道该列的更改指示的内容。对于本例,这意味着更新必须修改Salary属性以向其他客户端发出已发生更改的信号;仅影响其他属性的更新不会阻止并发更新。尽管存在这些问题,但如果您无法修改数据库并且能够确保客户端每次都更新特定属性,则使用并发令牌会很有用。


避免日期陷阱

您可以假设使用LastUpdated属性能避免并发令牌的限制,该属性在每次更新Employee对象时更新,并且不依赖于用户进行特定更改。不幸的是,您必须确保 Entity Framework Core 将查询存储在数据库中的值,以及日期可能会有精度和格式的变化。LastUpdated值是这样存储在数据库中的:

2017-11-10 09:11:42.3366667

但当 Entity Framework Core 读取这些值,并将之解析为DateTime对象,精度将丢失,格式会基于区域配置而更改。顶多,您将得到一个这样的值:

10/11/2017 09:11:42

当 Entity Framework Core 执行更新时,WHERE子句所指定的值将无法匹配数据库中的值,没有记录被匹配。Entity Framework Core 将认为并发检查失败,您将看到图21-10所示的异常。


使用行版本检测并发更新

如果您的项目允许更改数据库,则更可靠的替代方法是行版本(row version),这是一个时间戳,在更新时会自动更新,但以不会以导致格式差异的方式存储。清单 21-25 中,我向Employee类添加了一个属性,它将被用于行版本。

清单 21-25:Models 文件夹下的 Employee.cs 文件,添加属性

using System;

namespace AdvancedApp.Models
{
    public class Employee
    {
        private decimal databaseSalary;
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary
        {
            get => databaseSalary;
            set => databaseSalary = value;
        }
        public SecondaryIdentity OtherIdentity { get; set; }
        public bool SoftDeleted { get; set; } = false;
        public DateTime LastUpdated { get; set; }
        public byte[] RowVersion { get; set; }
    }
}

行版本使用byte数组属性实现,它避免了数据精度和格式的问题。在清单 21-26 中,我添加了一个 Fluent API 语句,它配置了用于行版本功能的新属性。我还注释了配置Salary属性作为并发令牌的方法调用。

注意:您可以通过应用TimeStamp特性来告诉 Entity Framework Core 行版本属性。这相当于使用 Fluent API 的IsRowVersion方法。

清单 21-26:Models 文件夹下的 AdvancedContext.cs 文件,添加行版本

using Microsoft.EntityFrameworkCore;
using System;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .HasQueryFilter(e => !e.SoftDeleted);

            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });

            modelBuilder.Entity<Employee>()
                .Property(e => e.Salary).HasColumnType("decimal(8,2)")
                .HasField("databaseSalary")
                .UsePropertyAccessMode(PropertyAccessMode.Field);
                //.IsConcurrencyToken();

            modelBuilder.Entity<Employee>().Property<DateTime>("LastUpdated")
                .HasDefaultValue(new DateTime(2000, 1, 1));

            modelBuilder.Entity<Employee>()
                .Property(e => e.RowVersion).IsRowVersion();

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new
                {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new
                {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });

            modelBuilder.Entity<SecondaryIdentity>()
                .Property(e => e.Name).HasMaxLength(100);
        }
    }
}

行版本功能是通过选择一个属性并调用IsRowVersion方法来配置的。为了防止并发更新,在编辑操作开始时存储在数据库中的RowVersion属性的值必须包含在发送到客户端的 HTML 中,以便在用户提交更改时由Update方法接收它 ,就像上一节中的例子一样。在清单 21-27 中,我向包含RowVersion属性值的 Edit.cshtml 视图添加了一个隐藏的input元素。

清单 21-27:Views/Home 文件夹下的 Edit.cshtml 文件,添加元素

@model Employee
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}

<h4 class="bg-info p-2 text-center text-white">
    Create/Edit
</h4>
<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <input type="hidden" asp-for="RowVersion" />
    <input type="hidden" name="originalSalary" value="@Model.Salary" />
    <div class="form-group">
        <label class="form-control-label" asp-for="SSN"></label>
        <input class="form-control" asp-for="SSN" readonly="@Model.SSN" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FirstName"></label>
        <input class="form-control" asp-for="FirstName"
               readonly="@Model.FirstName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FamilyName"></label>
        <input class="form-control" asp-for="FamilyName"
               readonly="@Model.FamilyName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="Salary"></label>
        <input class="form-control" asp-for="Salary" />
    </div>
    <input type="hidden" asp-for="OtherIdentity.Id" />
    <div class="form-group">
        <label class="form-control-label">Other Identity Name:</label>
        <input class="form-control" asp-for="OtherIdentity.Name" />
    </div>
    <div class="form-check">
        <label class="form-check-label">
            <input class="form-check-input" type="checkbox"
                   asp-for="OtherIdentity.InActiveUse" />
            In Active Use
        </label>
    </div>
    <div class="text-center">
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-secondary" asp-action="Index">Cancel</a>
    </div>
</form>

要使用 POST 请求中包含的接收值,我更新了 Home 控制器的Update方法,如清单21-28所示。

执行更新的技术基本上与前面的示例相同,重要的是不需要查询数据库中的当前RowVersion值,这将破坏并发性检查的目的。

清单 21-28:Controllers 文件夹下的 HomeController.cs 文件,使用行版本

...
[HttpPost]
public IActionResult Update(Employee employee)
{
    if (context.Employees.Count(e => e.SSN == employee.SSN
    && e.FirstName == employee.FirstName
    && e.FamilyName == employee.FamilyName) == 0)
    {
        context.Add(employee);
    }
    else
    {
        Employee e = new Employee
        {
            SSN = employee.SSN,
            FirstName = employee.FirstName,
            FamilyName = employee.FamilyName,
            RowVersion = employee.RowVersion
        };
        context.Employees.Attach(e);
        e.Salary = employee.Salary;
        e.LastUpdated = DateTime.Now;
    }
    context.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

注意,在执行更新时,不必更改RowVersion属性。数据库服务器将为每次更新生成一个新的RowVersion值,Entity Framework Core 在UPDATE语句的WHERE子句中使用该值。

最后,我需要将隐藏元素添加到 Home 控制器所使用的 Index 视图中,以便软删除功能正确执行,如清单21-29所示。

提示:注意,我使用了asp-for标签助手设置了input元素的值。RowVersion属性类型是byte数组,标签助手将数组元素连接起来形成一个字符串,MVC 模型绑定器可以在随后的 HTTP POST 请求中解析该字符串。

清单 21-29:Views/Home 文件夹下的 Index.cshtml 文件,添加元素

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th>Other Name</th>
            <th>In Use</th>
            <th>Last Updated</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
    <tr>
        <td>@e.SSN</td>
        <td>@e.FirstName</td>
        <td>@e.FamilyName</td>
        <td>@e.Salary</td>
        <td>@(e.OtherIdentity?.Name ?? "(None)")</td>
        <td>@(e.OtherIdentity?.InActiveUse.ToString() ?? "(N/A)")</td>
        <td>@e.LastUpdated.ToLocalTime()</td>
        <td class="text-right">
            <form>
                <input type="hidden" name="SSN" value="@e.SSN" />
                <input type="hidden" name="Firstname" value="@e.FirstName" />
                <input type="hidden" name="FamilyName"
                       value="@e.FamilyName" />
                <input type="hidden" name="RowVersion"
                       asp-for="@e.RowVersion" />
                <button type="submit" asp-action="Delete" formmethod="post"
                        class="btn btn-sm btn-danger">
                    Delete
                </button>
                <button type="submit" asp-action="Edit" formmethod="get"
                        class="btn btn-sm btn-primary">
                    Edit
                </button>
            </form>
        </td>
    </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

行版本方法的缺点是需要对数据库进行更改。在 AdvancedApp 项目文件夹中运行清单21-30所示的命令,以创建和应用名为 RowVersion 的迁移。

清单 21-30:创建并应用数据库迁移

dotnet ef migrations add RowVersion
dotnet ef database update

如果使用两个浏览器窗口执行交错更新,则会看到图21-10所示的相同错误消息。不同之处在于,所有更新都将触发行版本值的更改。这意味着所有更新 —— 不管它们修改了什么属性 —— 都将导致一个可以用于检测并发更新的更改。

总结

在本章中,我介绍了 Entity Framework Core 用于控制创建或存储在数据库中的方式的功能。我解释了如何更改用于在数据库中存储属性的 SQL 类型,如何使用幕后字段验证或格式化数据,如何对不希望应用程序的其他部分访问的数据使用影子属性,以及如何在数据库中存储对象时设置默认值。在结束本章时,我向您展示了如何使用并发令牌和行版本功能检测并发更新。在下一章中,我将描述可用于删除数据的高级功能。

;

© 2018 - IOT小分队文章发布系统 v0.3